To make reading the NLB Framework code a little easier, the following is an explanation of some of the idiosyncratic conventions used within it and the rationale behind them. Whilst you are free to use any conventions you wish in your own third-party code, if you wish to submit changes or suggest additions to the framework codebase, abiding by these conventions will make it easier and less work for that code to be included.
Many of these conventions are the result of over thirty years experience in reviewing and refactoring similar codebases, and may at first appear unnecessarily pedantic. However, readability and being able to quickly distinguish between different things are key to all these conventions, so when searching for things or just visually scanning though the code, some familiarity with them will very likely help at some point.
Basic Formatting
As a linting preference, the codebase uses 4 spaces rather than tabs to indent code blocks, and should automatically remove trailing white space from the end of all lines.
All formatting rules are preference-based so there are no hard and fast solutions to every situation. However, the following example code demonstrates most of the formatting conventions as they are implemented within the framework.
function myFunction01(myArgOne, myArgTwo) {
// ...
};
const myFunction02 = function(myArgOne, myArgTwo) {
// ...
};
const myFunction03 = (argOne, argTwo) => {
// ...
};
const my_array_one = [ 0, 1, 0, 5 ];
const my_array_two = [
1, 2, 4, 9, 9, 4, 3, 1,
9, 4, 3, 1, 1, 2, 4, 9,
1, 5, 3, 2, 2, 5, 3, 1
];
const my_array_three = [
-401, 472, 432, 657, 904, 423, 334, 109,
956, 4, 3, 1, 112, 2, 42, 9,
0, 35, 34, 2, 239, 523, 35, 13
];
const my_object_one = { x: 100.0, y: 200.0, z: 0.0 };
const my_object_two = {
propertyAA: 'valueBF',
longerNameSD: 'anotherValueJS',
shortQX: 'valueD9',
differentXY: 'valueSQ'
};
for (let ii = 0; ii < my_array_two.length; ++ii) {
doSomethingWith(my_array_two[ii]);
}
for (const item of Object.values(myObject)) {
x = 12 + (107.61 * (PD.Utils.toNumber(Math.abs(item), 0.0) / 10));
// ...
}
while (somethingIsHappening()) {
doSomethingElse(
element.path,
PD.Utils.Z_Axis,
PD.FORWARD,
++offset
);
// ...
}
The specific conventions demonstrated in the above code are:
-
Individual statements and definitions are explicitly terminated with a semicolon, whilst internal code blocks bounded by braces are not. Thus, the end square brackets and braces for all variable and function definitions (such as myFunction01, my_array_two and my_object_two) are terminated with a semicolon, but the
forandwhilecode blocks are not. -
When calling a function/method, the name should be immediately followed by the opening brace without any whitespace in between in order to visually differentiate it from the use of a variable. This makes searching for
functionName(across multiple files find all calls to that function/method. -
Keywords such as
forandwhileshould be separated from their opening bracket by a single space to visually differentiate them from function/method calls. Most code editors show such keywords using a different color, so they are already visually distinct from variable names. -
The
functionkeyword is the only exception to this, which is admittedly somewhat arbitrary, but does seem to look and feel better when expressed asfunction(...). -
Equal signs, comparisons and mathematical/bitwise operators should be separated by spaces both before and after to make reading equations a bit easier.
-
When a comma is used to separate arguments or array entries, it must be followed by a single space or a carriage return to visually separate each of the items.
-
When initialising long arrays or listing multiple arguments, it is often desirable to spread them across multiple lines, as demonstrated by my_array_three, my_object_two and doSomethingElse, and if appropriate, to align them vertically into visually discernable columns. This typically makes it easier to read corresponding numbers and values.
-
When assigning properties in an object literal, the colon between the name and value should be separated by at least one space. This applies to single and multi-line assignments, as demonstrated by my_object_one and my_object_two. When using multi-line assignments (as shown for my_object_two), it is desirable to make some reasonable attempt at aligning the property names and values vertically in columns if it makes the values easier to read/scan.
-
To visually differentiate the initialisation of an array from the act of accessing items within an array, inline initialisation should include a space immediately after the opening square bracket and immediately before the closing square bracket. This should result in
x = [ 1, 2, 3 ];as opposed tox = [1, 2, 3];, making it possible to search through a file for[to get all array initialisations as distinct from array accesses. -
The closing brace of a function/method definition should be immediately followed by semicolon to visually differentiate its end from the end of any enclosed code blocks. This is true even for methods defined within a ES6 class definition. The only exception is for methods defined within an object literal such as
const myObj = { /*...*/ }, where the end brace must be followed by a comma. -
To differentiate
forandfor-of/for-inloops, the framework usesletfor variables whose values are incremented/decremented as part of a loop, andconstfor variables assigned values from an iterable usingoforin.
Documenting Functions and Methods
A typical function/method definition within the codebase should look very similar to the following example.
/**
* Inserts item at the given index, if not already in the array.
*
* You can add additional longer paragraphs describing the method
* or property after the initial concise summary. These can include
* <pre> and <code> blocks or even be an @example.
*
* This function uses `Array#indexOf()` to check if the item already
* exists in the array. If not, it is inserted using `Array#splice()`.
*
* __Example Sub Heading__
*
* Don't use `###` of `***` headings imn
* Don't use `###` or `***` headings within the JSDoc comments of a
* class or function as the template will add them as spurious large
* heading entries in the legend on the right hand side and be very
* confusing. Instead the convention shown here by placing headings
* on a line by themselves and bolded it using `__`.
*
* __NOTE:__ If you want to draw attention to something, use a note
* such as this. It is bolded in the same way as a heading, but is not
* on a line by itself.
*
* @param {Array<any>} value The array to add the item to.
* @param {number} index The ordinal index to insert at within the array.
* @param {any} item The item to insert if not already in the array.
* @returns {boolean} Returns true if the item was added to the array,
* otherwise false.
**/
PD.Utils.insertInArray = function(array, index, item) {
const existing_index = array.indexOf(item);
if (existing_index < 0) {
array.splice(index, 0, item);
return true;
}
return false;
};
The important things to note are:
-
If the function/method is to be included in documentation, it must be immediately preceded by a JSDoc3 compatible comment that complies with the following:
-
A single sentence synopsis of what the function/method does, followed by any number of optional paragraph(s) and/or note(s) that contain information useful to other programmers reading the documentation or code, all in markdown format.
-
A list of
@paramtags, one for each argument the function/method takes, which includes its type, name and a short description even if you think the name is self explanatory. -
A
@returnstag that includes the type and description of whatever the the function/method returns, if anything. -
A
@throwstag if the function/method throws any exceptions or errors. -
A reasonable attempt to align the type, name and descriptions into visually discernable vertical columns to make it easier to read/scan the various tags.
-
-
The function/method definition with the name, parenthesised arguments and opening brace all on the same line.
-
The contents of the method should be indented from the function/method definition.
-
A semicolon after the closing brace to visually differentiate the end of the function/method from the end of any enclosed code blocks. This is true even for methods defined within an ES6 class definition. The only exception is for methods defined within an object literal such as
const myObj = { /*...*/ }, where the end brace must be followed by a comma instead of a semicolon. -
Don't use
###or***headings within the JSDoc comments of a class or function. If you do, they will be added as spurious large heading entries in the legend on the right hand side and be very confusing. Instead the convention adopted here is to include headings in JSDoc comments on a line by themselves and bolded using__I am a Heading__.
Class Layout
Whilst static global classes are created as object literals, and there are some legacy SVG charts that use constructor functions, the preferred method for defining and extending classes within the NLB Framework is using ES6 classes.
The following is an example of the type of class definition that a third-party developer is likely to create at some point. This class extends an existing class, in this case BIM.Element.External, and contains all the boilerplate code required, as well as the JSDoc comments required for documenting it.
// ========================================================================
// * CLASS: BIM > MASS
// ========================================================================
/**
* Defines a simple mass element.
*
* Masses can be used to represent external shading, surrounding buildings,
* site obstructions or any other solid geometric form that is not part of the
* building and whose purpose is not structural.
* @extends BIM.Element.External
* @author drajmarsh
* @class
**/
BIM.Mass = class extends BIM.Element.External {
/**
* Creates a new 3D mass shape.
*
* @param {object} [config] An optional configuration object.
* @param {string} [config.name] An optional human-readable name for this item, defaults to class type.
* @param {string} [config.uuid] An optional universally unique identifier of this item, defaults to a new UUID.
* @param {boolean} [config.visible] Whether or not the entity is initially visible within the model, defaults to true.
* @param {object|Map} [config.attributes] An optional object or map with any arbitrary attributes to associate with this entity.
* @param {THREE.Box3} [config.extents] An optional bounding box with the initial extents of the entity in model space.
* @param {BIM.Path} [config.path] An optional path for this element's geometry to follow, defaults to an empty path.
* @param {PD.Shell} [config.shell] An optional shell to use when generating element geometry, defaults to a new shell.
* @param {number} [config.height] The height of this element if applicable, defaults to zero.
* @param {BIM.SYSTEM} [config.systems] The building system(s) this element contributes to or draws from.
* @param {BIM.ELEMENT} [config.elementType] The BIM/IFC/gbXML element type enumerator for this element, defaults to BIM.ELEMENT.SHADE.
* @param {BIM.Component} [config.typeComponent] An optional component to define the type of element.
* @param {number} [config.subType] An optional element sub-type enumerator, defaults to zero.
* @param {number} [config.outerOffset] An offset around the external boundary of the element path in mm, defaults to zero.
* @param {number} [config.innerOffset] An offset around any internal boundaries within the element path in mm, defaults to zero.
* @param {number} [config.clipToLevel] Whether or not this element clips itself to the external building envelope, defaults to true.
* @param {number} [config.cutAndFill] Whether or not this element cuts and fills terrain elements on the same level, defaults to true.
* @param {number} [config.faceUpwards] Whether or not this element's surface should always face upwards, defaults to false.
* @param {number} [config.someProperty] A property that stores and integer value for some reason, defaults to 1.
* @param {string} [typeName] An additional parameter used by subclasses to set the element type name without
* modifying the `config` object.
**/
constructor(config, typeName) {
config = config || {};
super(config, typeName || 'Mass');
// Use a different default value than parent class.
this.elementType = PD.Utils.toInteger(config.elementType, BIM.ELEMENT.SHADE);
// ------------------------
// Public properties.
/**
* A property that stores an integer value for some reason, defaults to 1.
* @type {number}
*/
this.someProperty = PD.Utils.toIntegerInRange(config.someProperty, 1, 0, 10);
};
// --------------------------------------------------------------------
// Keep JSON methods with constructor to make changes easier.
/**
* Converts the object instance to a simple POJO for JSON storage.
*
* This method is used to copy, store and save the data for ths object, so
* the returned object must have all the properties required be able to rebuild
* this instance in its entirety when passed to the class constructor.
*
* See the PD.Base#toJSON method for more details.
*
* @param {object} [data] An optional parent object to append this data to.
* @returns {object} Returns a JSON object.
* @override
**/
toJSON(data) {
data = super.toJSON(data);
data.someProperty = this.someProperty;
return data;
};
/**
* Safely copy properties from a source object.
*
* See the PD.Base#fromJSON method for more details.
*
* @param {object} data The source object containing data to copy.
* @returns {BIM.Mass} Returns this instance to support method chaining.
* @override
**/
fromJSON(data) {
super.fromJSON(data);
if ('someProperty' in data) {
this.someProperty = PD.Utils.toIntegerInRange(data.someProperty, this.someProperty, 0, 10);
}
return this;
};
// --------------------------------------------------------------------
// Getters and Setters.
/**
* A flag identifying this object as a mass element.
* @type {boolean}
* @readonly
**/
get isMass() {
return true;
};
// --------------------------------------------------------------------
// Overridden Methods.
/**
* Provides a list of dynamic parameter groups for this element.
*
* See the PD.Base#getDynamicParameters method for more details.
*
* @returns {Array} Returns an array of PD.ParamGroup objects.
* @override
**/
getDynamicParameters() {
// Get groups from parent class.
const groups = super.getDynamicParameters();
// Check first group.
const group = groups[0];
if (group) {
// Give it a new title.
group.name = 'massParams';
group.title = 'Mass Parameters';
// Modify height input name.
group.setParameterProperty('height',
'title', 'Extrusion Height'
);
}
return groups;
};
// --------------------------------------------------------------------
// Overridden Static Methods.
/**
* Defines the type of BIM entity this class represents.
*
* See BIM.Entity.getEntityType for more details as this is required
* for use with the PD.Registry.
*
* @returns {BIM.ENTITY} Returns the BIM entity type this class represents.
* @override
* @static
**/
static getEntityType() {
return BIM.ENTITY.MASS;
};
/**
* A brief description of this class to accompany its icon.
*
* @returns {string} Returns a brief description.
* @override
* @static
**/
static getClassDescription() {
return 'An extruded mass element for external shading.';
};
/**
* The name of this class within the PD.Registry.
*
* See PD.Base.getClassName for more details as this is required
* for use with the PD.Registry.
*
* @returns {string} Returns the registered name of this class.
* @override
* @static
**/
static getClassName() {
return 'BIM.Mass';
};
};
/**
* The icon associated with this class in the PD.Registry.
*
* See PD.Base.icon for more information on this object format.
* @type {object}
**/
BIM.Mass.icon = {
content: '<g fill="none" stroke="currentColor"><path d="m80 240 420-200 420 100v520l-140 220-480 80-220-200z" stroke-width="50"/><g stroke-width="20"><path d="m80 240 220 200 480-80 140-220"/><path d="m300 440v520"/><path d="m780 360v520"/></g></g>'
};
// Register element class.
PD.Registry.registerClass(BIM.Mass, BIM.Mass.icon);
Some conventions to note are:
-
To properly document the class, it needs JSDoc comments for virtually every part of it, including:
-
Both
@extendsand@classtags in the comments for the class itself. -
The comments for the constructor should include
@paramtags for all the configuration options its parent class(s) constructor looks for, as well as those for this class. -
All overridden methods need an
@overridetag in their comment. -
All new and overridden static methods need a
@statictag in their comment.
-
-
As such class definitions need to by updated manually, I tend to keep the
toJSON()andfromJSON()method definitions immediately after the constructor to make it easy to update them as well whenever a property is added, removed or renamed. -
...
Code Comments
Other than for JSDoc purposes, views on the role of comments within the general flow of code vary widely. Some believe that the code should speak for itself and that comments are a distraction. Others believe that you should only comment on things that are not immediately obvious from the code itself, anything else is redundant. Then there are those like me, who comment prolifically and with abandon.
There are many times when I have lamented not commenting properly on code when I return to it several years later, so I now tend to comment prolifically and somewhat redundantly. For me, redundant comments that summarise a block or group of code are particularly useful as they let me quickly scan through to find the specific bit I'm looking for. Yes, I could read through the code itself to work it out instead, but that often adds unnecessary time and additional cognitive load. This is mainly because I now know from experience what comments are likely to help me do this, and that those same comments may not be of much help to someone else.
However, given that the codebase is full of such comments, it is worth some time to explain and rationalise. Taking the following example code, if I come across an issue when selecting a junction by its shell, I only need to read three lines from the following method to know which part of it does what and where I need to investigate further.
/**
* Checks if the given ray intersects this element and updates the selection.
*
* @param {PD.Selection} selection The interactive selection accumulator.
* @returns {boolean} Returns true if a path segment in the element was selected
* and the selection updated, otherwise false.
* @override
**/
findByRay(selection) {
let found = false;
const path = this.path;
const ray = selection.ray;
// Check path polygons.
if (path && path.isClosed && path.plane) {
if (ray.intersectPlane(path.plane, selection.intersection)
&& path.isPointOnBoundary(selection.intersection, selection.closestRadius)
&& selection.foundElement(this, selection.intersection)) {
selection.closestRadius = 1.0;
found = true;
}
}
if (!found) { // Check junction facets.
for (const contour of path.getContours()) {
for (const junction of contour) {
if ((junction.shell.findByRay(selection) != null)
&& selection.foundElement(this, selection.intersection)) {
selection.closestRadius = 1.0;
found = true;
break;
}
}
}
}
// Check shell facets.
if (!found && this.shell && this.shell.hasContent()) {
const facet = this.shell.findByRay(selection);
if ((facet != null) && selection.foundElement(this, selection.intersection)) {
selection.closestRadius = 1.0;
found = true;
}
}
return found;
};
This kind of commenting helps me even more in larger methods with many steps, especially when I return to it after having long forgotten what I did and why. Also, if I had to work something out in my head in order to write or refactor the code, I try to include as much of that as I can within a comment at the point where it is used, as shown in the following example.
/**
* Computes core properties required for this junction when it sits in a path.
*
* This method essentially calculates and updates all the path properties stored
* by the junction.
*
* @param {BIM.Path} path The path that this junction is part of.
* @returns {BIM.Junction} Returns this junction to support method chaining.
**/
computeProperties(path) {
let prev_junction = this.prev; // Check if co-incident.
if (prev_junction && (prev_junction.manhattanDistanceTo(this) < 0.1)) {
prev_junction = prev_junction.prev;
}
let next_junction = this.next; // Check if co-incident.
if (next_junction && (next_junction.manhattanDistanceTo(this) < 0.1)) {
next_junction = next_junction.next;
}
// Disconnected junction.
if (!next_junction && !prev_junction) {
// Use +X axis with zero scale.
this.inVec4.x = this.outVec4.x = 1.0;
this.inVec4.y = this.outVec4.y = 0.0;
this.inVec4.z = this.outVec4.z = 0.0;
this.inVec4.scale = this.outVec4.scale = 0.0;
// Normals and seam point in -Y axis,
// following the right-hand rule:
//
// Z Y +Z
// |/ | +Y
// +---X | /
// |/
// prev +--->---------+----------->---+ next
// /_/ this /_/
// / /
// / /
// Use -Y axis.
this.inNormal.x = this.outNormal.x = this.normal.x = 0.0;
this.inNormal.y = this.outNormal.y = this.normal.y = -1.0;
this.inNormal.z = this.outNormal.z = this.normal.z = 0.0;
// Use +Z axis.
this.upVector.x = 0.0;
this.upVector.y = 0.0;
this.upVector.z = 1.0;
// Clear values.
this.cornerInset = 0.0;
this.signedAngleInRadians = 0.0;
this.angle = 0.0;
}
else { // Connected junction.
if (prev_junction) { // In from previous.
this.inVec4.setFromPoints(prev_junction, this);
if (!next_junction) { // Reflect incoming.
this.outVec4.copy(this.inVec4);
this.outVec4.scale = 0.0;
}
}
if (next_junction) { // Out to next.
this.outVec4.setFromPoints(this, next_junction);
if (!prev_junction) { // Reflect outgoing.
this.inVec4.copy(this.outVec4);
this.inVec4.scale = 0.0;
}
}
// next
// +
// /
// ..''''o.
// .'' / ''.
// : / :
// 180 - angle / angle :
// prev +------o------+ - - -o - >
// this
// Calculate angle between incoming and outgoing vectors.
const cosine_of_angle = this.inVec4.dot(this.outVec4);
const angle_in_radians = Math.PI - ((cosine_of_angle >= 1.0) ? 0.0 : (cosine_of_angle <= -1.0) ? Math.PI : Math.acos(cosine_of_angle));
const cross_product = g_tempVec1.crossVectors(this.inVec4, this.outVec4).normalize();
// Store lesser angle in degrees.
this.angle = PD.Utils.radToDeg(Math.abs(angle_in_radians));
// -----
// If points co-linear or cross-product invalid, use path normal.
if ((this.angle < 0.1) || (this.angle > 179.9) || (cross_product.manhattanLength() < 0.1)) {
this.upVector.copy(path.plane.normal);
this.isConcave = false;
}
// Check to use +Z-axis.
else if (path.keepVertical) {
this.isConcave = (cross_product.z < -0.001);
this.upVector.x = 0.0;
this.upVector.y = 0.0;
this.upVector.z = 1.0;
}
else { // Use cross-product.
this.isConcave = (path.plane.normal.dot(cross_product) < -0.001);
this.upVector.copy(cross_product);
}
// -----
// Determine the sign of the angle using up-vector.
this.signedAngleInRadians = (cross_product.dot(this.upVector) < -1e-6) ? angle_in_radians : -angle_in_radians;
// Calculate incoming and outgoing normals.
this.outNormal.crossVectors(this.outVec4, this.upVector).normalize();
this.inNormal.crossVectors(this.inVec4, this.upVector).normalize();
// Check to flip normals.
if (this.isConcave && !path.keepVertical) {
this.outNormal.negate();
this.inNormal.negate();
}
// Calculate normal at junction.
if (this.angle > 0.099) { // Calculate average vector.
this.normal.addVectors(this.inNormal, this.outNormal).normalize();
} else { // A U-turn means normal is in incoming direction.
this.normal.copy(this.inVec4);
}
// Compute inset due to internal wall.
this.cornerInset = PD.Utils.safeDivide(this.innerEdgeOffset, Math.tan(this.signedAngleInRadians * 0.5));
if (this.isConcave) this.cornerInset = Math.abs(this.cornerInset);
else this.cornerInset = Math.max(0.0, this.cornerInset);
}
// Check curve.
if (this.curve) {
this.curve.update();
}
// Update previous curve.
// If the path data of this junction has changed, then
// we need to recompute the end of the previous curve.
if (this.prev?.curve) {
this.prev.curve.computeEndConnection();
}
// Update any apertures.
if (this.apertureList.length > 0) {
for (let ii = 0, ii_max = this.apertureList.length; ii < ii_max; ++ii) {
this.apertureList[ii].update(this);
}
}
// Clear flag(s).
this.hasChanged = false;
return this;
};
JSDoc Comments in Example Code
For some reason, when JSDoc comments are included in JSDoc-generated documentation (such as in these tutorial pages), it sometimes gets confused at the terminating comment line, so the code formatting from there on is broken. The only way I have found to fix this is to add an extra asterisk in the terminating comment line. This is only required for comments within code that is embedded in documentation, not in the actual code.
Hopefully the following is an example of it breaking (as I said, it happens 'sometimes'),
/**
* This is an example JSDoc comment for a method/function to demonstrate
* how it is sometimes broken when included in example code.
*
* The last line of this comment has a typical terminator with a single
* asterisk followed by a forward slash.
*
* @param {BIM.Path} path The path that this junction is part of.
* @returns {BIM.Junction} Returns this junction to support method chaining,
* adding another line here may be the culprit.
*/
someFunctionThatDoesSomething(path) {
let prev_junction = this.prev; // Check if co-incident.
if (prev_junction && (prev_junction.manhattanDistanceTo(this) < 0.1)) {
prev_junction = prev_junction.prev;
}
let next_junction = this.next; // Check if co-incident.
if (next_junction && (next_junction.manhattanDistanceTo(this) < 0.1)) {
next_junction = next_junction.next;
}
// ....
};
The following is the same code but with the fix applied.
/**
* This is an example JSDoc comment for a method/function to demonstrate
* how to fix it if is breaks for you.
*
* The last line of this comment has an atypical terminator with two
* asterisks followed by a forward slash.
*
* @param {BIM.Path} path The path that this junction is part of.
* @returns {BIM.Junction} Returns this junction to support method chaining,
* adding another line here may be the culprit.
**/
someFunctionThatDoesSomething(path) {
let prev_junction = this.prev; // Check if co-incident.
if (prev_junction && (prev_junction.manhattanDistanceTo(this) < 0.1)) {
prev_junction = prev_junction.prev;
}
let next_junction = this.next; // Check if co-incident.
if (next_junction && (next_junction.manhattanDistanceTo(this) < 0.1)) {
next_junction = next_junction.next;
}
// ....
};